بازگشت به لیست مقالات

چرخش (Rotation) توکن‌های Refresh در ASP.NET Core: پیاده‌سازی امن و تشخیص سرقت توکن

Refresh token rotation .net core markdown امتیاز: 9/100 مفید تأیید شده

چرخش (Rotation) توکن‌های Refresh در ASP.NET Core: پیاده‌سازی امن و تشخیص سرقت توکن

مقدمه

در معماری‌های مبتنی بر JWT (JSON Web Token)، توکن دسترسی (Access Token) معمولاً عمر کوتاهی دارد (۵ تا ۱۵ دقیقه) تا در صورت نشت، آسیب محدود باشد. اما برای اینکه کاربر مجبور نباشد مدام وارد سیستم شود، از Refresh Token استفاده می‌کنیم؛ توکنی با عمر طولانی‌تر که امکان دریافت Access Token جدید را بدون ورود مجدد فراهم می‌کند.

مشکل اصلی این است: اگر یک Refresh Token ثابت و طولانی‌مدت به سرقت برود، مهاجم می‌تواند تا زمان انقضای آن، به‌طور نامحدود Access Token جدید دریافت کند. راه‌حل این مشکل، تکنیکی به نام Refresh Token Rotation است که در ادامه به‌طور کامل آن را در ASP.NET Core پیاده‌سازی می‌کنیم.

این مقاله بر اساس RFC 6749 و توصیه‌های OAuth 2.0 Best Current Practice نوشته شده است.

مفاهیم پایه

Access Token در برابر Refresh Token

ویژگی Access Token Refresh Token
عمر کوتاه (دقیقه‌ای) طولانی (روز تا هفته)
محل استفاده API endpoints فقط endpoint توکن
محل ذخیره حافظه / Memory HttpOnly Cookie یا Secure Storage
هدف احراز هویت در هر درخواست دریافت Access Token جدید

مشکل Refresh Token ثابت

در پیاده‌سازی‌های ساده‌لوحانه، Refresh Token تا زمان انقضا معتبر است. هیچ مکانیزمی برای:

  • باطل کردن آن پس از استفاده،
  • تشخیص استفاده مجدد (Reuse)،
  • شناسایی سرقت،

وجود ندارد.

Refresh Token Rotation چیست؟

چرخش توکن Refresh به این معناست که به ازای هر بار استفاده از Refresh Token برای دریافت Access Token جدید، یک Refresh Token جدید هم صادر شده و توکن قبلی باطل (Revoke) می‌شود.

مزایای کلیدی

  • کاهش پنجره حمله: هر توکن فقط یک‌بار قابل استفاده است.
  • تشخیص سرقت: اگر توکن قبلی (قبلاً استفاده‌شده) مجدداً استفاده شود، یعنی توکن به سرقت رفته است.
  • ابطال زنجیره‌ای (Token Family Revocation): در صورت تشخیص سرقت، تمام توکن‌های متعلق به آن خانواده باطل می‌شوند.

تشخیص استفاده مجدد (Reuse Detection)

وقتی کاربر وارد سیستم می‌شود، یک Refresh Token اولیه صادر می‌شود. هر بار چرخش، توکن جدیدی تولید می‌شود که در دیتابیس به توکن قبلی (به‌عنوان ReplacedByToken) متصل می‌شود. این یک خانواده توکن (Token Family) را تشکیل می‌دهد.

Login → RT1
Use RT1 → RT2 (RT1 revoked, ReplacedBy = RT2)
Use RT2 → RT3 (RT2 revoked, ReplacedBy = RT3)
Use RT3 → RT4 (RT3 revoked, ReplacedBy = RT4)

سناریوی سرقت:

  • مهاجم RT2 را می‌دزدد و قبل از کاربر قانونی از آن استفاده می‌کند.
  • سرور RT3 را به مهاجم می‌دهد و RT2 را باطل می‌کند.
  • کاربر قانونی تلاش می‌کند از RT2 (که قبلاً استفاده شده) استفاده کند.
  • سرور تشخیص می‌دهد که RT2 قبلاً استفاده شده است → سرقت تشخیص داده شد.
  • تمام خانواده توکن‌ها (RT1, RT2, RT3, RT4) باطل می‌شوند و کاربر مجبور به ورود مجدد می‌شود.

ساختار پروژه

ابتدا پروژه‌ای با مشخصات زیر ایجاد می‌کنیم:

dotnet new webapi -n TokenRotationDemo
cd TokenRotationDemo
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package System.IdentityModel.Tokens.Jwt

مدل دیتابیس

کلاس RefreshToken

// Entities/RefreshToken.cs
using System.ComponentModel.DataAnnotations;

namespace TokenRotationDemo.Entities;

public class RefreshToken
{
    [Key]
    public Guid Id { get; set; }

    // هش توکن در دیتابیس ذخیره می‌شود، نه خود توکن
    public string TokenHash { get; set; } = string.Empty;

    // شناسه خانواده توکن (همان JIT اولیه صادر شده هنگام لاگین)
    public string FamilyId { get; set; } = string.Empty;

    // شناسه JWT (jti claim) که این توکن به آن متصل است
    public string JwtId { get; set; } = string.Empty;

    public DateTime ExpiresAt { get; set; }
    public DateTime CreatedAt { get; set; }

    public string CreatedByIp { get; set; } = string.Empty;

    public DateTime? RevokedAt { get; set; }
    public string? RevokedByIp { get; set; }

    // توکنی که جایگزین این توکن شده است
    public string? ReplacedByTokenHash { get; set; }

    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsRevoked => RevokedAt != null;
    public bool IsActive => !IsRevoked && !IsExpired;

    // رابطه با کاربر
    public Guid UserId { get; set; }
    public virtual User User { get; set; } = null!;
}

کلاس User

// Entities/User.cs
using System.ComponentModel.DataAnnotations;

namespace TokenRotationDemo.Entities;

public class User
{
    [Key]
    public Guid Id { get; set; }

    [Required]
    public string Username { get; set; } = string.Empty;

    [Required]
    public string PasswordHash { get; set; } = string.Empty;

    public ICollection<RefreshToken> RefreshTokens { get; set; } = new List<RefreshToken>();
}

ApplicationDbContext

// Data/ApplicationDbContext.cs
using Microsoft.EntityFrameworkCore;
using TokenRotationDemo.Entities;

namespace TokenRotationDemo.Data;

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options) { }

    public DbSet<User> Users => Set<User>();
    public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<RefreshToken>()
            .HasOne(rt => rt.User)
            .WithMany(u => u.RefreshTokens)
            .HasForeignKey(rt => rt.UserId);

        modelBuilder.Entity<RefreshToken>()
            .HasIndex(rt => rt.TokenHash)
            .IsUnique();

        modelBuilder.Entity<RefreshToken>()
            .HasIndex(rt => rt.FamilyId);
    }
}

تنظیمات JWT

فایل appsettings.json

{
  "JwtSettings": {
    "Issuer": "TokenRotationDemo",
    "Audience": "TokenRotationDemoClients",
    "AccessTokenExpirationMinutes": 15,
    "RefreshTokenExpirationDays": 7,
    "SecretKey": "SuperSecretKeyAtLeast32CharactersLongForHmacSha256!!"
  },
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=tokens.db"
  }
}

کلاس JwtSettings

// Options/JwtSettings.cs
namespace TokenRotationDemo.Options;

public class JwtSettings
{
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int AccessTokenExpirationMinutes { get; set; } = 15;
    public int RefreshTokenExpirationDays { get; set; } = 7;
    public string SecretKey { get; set; } = string.Empty;
}

سرویس مدیریت توکن

این سرویس هسته اصلی منطق چرخش توکن است:

// Services/ITokenService.cs
using TokenRotationDemo.Entities;

namespace TokenRotationDemo.Services;

public interface ITokenService
{
    (string accessToken, string refreshToken, DateTime refreshTokenExpiry) GenerateTokensForUser(User user, string ip);
    Task<(string accessToken, string refreshToken)?> RotateRefreshTokenAsync(string refreshToken, string ip);
    Task RevokeByFamilyAsync(string familyId, string ip);
}
// Services/TokenService.cs
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using TokenRotationDemo.Data;
using TokenRotationDemo.Entities;
using TokenRotationDemo.Options;

namespace TokenRotationDemo.Services;

public class TokenService : ITokenService
{
    private readonly ApplicationDbContext _db;
    private readonly JwtSettings _jwtSettings;

    public TokenService(ApplicationDbContext db, JwtSettings jwtSettings)
    {
        _db = db;
        _jwtSettings = jwtSettings;
    }

    public (string accessToken, string refreshToken, DateTime refreshTokenExpiry) GenerateTokensForUser(User user, string ip)
    {
        var jwtId = Guid.NewGuid().ToString();
        var familyId = Guid.NewGuid().ToString();
        var accessToken = GenerateAccessToken(user, jwtId);
        var refreshToken = GenerateRefreshToken();
        var expiry = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpirationDays);

        var dbToken = new RefreshToken
        {
            Id = Guid.NewGuid(),
            TokenHash = HashToken(refreshToken),
            FamilyId = familyId,
            JwtId = jwtId,
            ExpiresAt = expiry,
            CreatedAt = DateTime.UtcNow,
            CreatedByIp = ip,
            UserId = user.Id
        };

        _db.RefreshTokens.Add(dbToken);
        _db.SaveChanges();

        return (accessToken, refreshToken, expiry);
    }

    public async Task<(string accessToken, string refreshToken)?> RotateRefreshTokenAsync(string refreshToken, string ip)
    {
        var tokenHash = HashToken(refreshToken);
        var existing = await _db.RefreshTokens
            .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);

        // اگر توکن وجود نداشت → احتمالاً دستکاری شده است
        if (existing is null)
            return null;

        // توکن منقضی یا باطل شده → امکان چرخش نیست
        if (!existing.IsActive)
            return null;

        var user = await _db.Users.FindAsync(existing.UserId);
        if (user is null)
            return null;

        // تولید توکن‌های جدید در همان خانواده
        var newJwtId = Guid.NewGuid().ToString();
        var newAccessToken = GenerateAccessToken(user, newJwtId);
        var newRefreshToken = GenerateRefreshToken();
        var newExpiry = DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpirationDays);

        var newDbToken = new RefreshToken
        {
            Id = Guid.NewGuid(),
            TokenHash = HashToken(newRefreshToken),
            FamilyId = existing.FamilyId,
            JwtId = newJwtId,
            ExpiresAt = newExpiry,
            CreatedAt = DateTime.UtcNow,
            CreatedByIp = ip,
            UserId = user.Id,
            ReplacedByTokenHash = null
        };

        // باطل کردن توکن قبلی و ثبت جایگزین آن
        existing.RevokedAt = DateTime.UtcNow;
        existing.RevokedByIp = ip;
        existing.ReplacedByTokenHash = newDbToken.TokenHash;

        _db.RefreshTokens.Add(newDbToken);
        await _db.SaveChangesAsync();

        return (newAccessToken, newRefreshToken);
    }

    public async Task RevokeByFamilyAsync(string familyId, string ip)
    {
        var familyTokens = await _db.RefreshTokens
            .Where(rt => rt.FamilyId == familyId && rt.IsActive)
            .ToListAsync();

        foreach (var token in familyTokens)
        {
            token.RevokedAt = DateTime.UtcNow;
            token.RevokedByIp = ip;
        }

        await _db.SaveChangesAsync();
    }

    private string GenerateAccessToken(User user, string jwtId)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Name, user.Username),
            new Claim(JwtRegisteredClaimNames.Jti, jwtId)
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var token = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpirationMinutes),
            signingCredentials: creds);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    private static string GenerateRefreshToken()
    {
        var bytes = RandomNumberGenerator.GetBytes(64);
        return Convert.ToBase64String(bytes);
    }

    private static string HashToken(string token)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(token));
        return Convert.ToBase64String(bytes);
    }

    public string HashTokenPublic(string token) => HashToken(token);
}

Endpoint تشخیص سرقت (Reuse Detection)

قطعه‌حساس تشخیص سرقت باید قبل از چرخش انجام شود. این کار را در یک متد واسط در Controller یا در Middleware انجام می‌دهیم. در اینجا برای شفافیت، در Controller پیاده می‌کنیم:

// Controllers/AuthController.cs
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using TokenRotationDemo.Data;
using TokenRotationDemo.Entities;
using TokenRotationDemo.Services;

namespace TokenRotationDemo.Controllers;

[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
    private readonly ApplicationDbContext _db;
    private readonly ITokenService _tokenService;

    public AuthController(ApplicationDbContext db, ITokenService tokenService)
    {
        _db = db;
        _tokenService = tokenService;
    }

    [HttpPost("login")]
    public async Task<IActionResult> Login([FromBody] LoginDto dto)
    {
        var user = await _db.Users.FirstOrDefaultAsync(u => u.Username == dto.Username);
        if (user is null)
            return Unauthorized("Invalid credentials");

        // در پروژه واقعی، از BCrypt/Pbkdf2 برای بررسی پسورد استفاده کنید
        if (!VerifyPassword(dto.Password, user.PasswordHash))
            return Unauthorized("Invalid credentials");

        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        var (accessToken, refreshToken, expiry) =
            _tokenService.GenerateTokensForUser(user, ip);

        SetRefreshTokenCookie(refreshToken, expiry);

        return Ok(new { accessToken });
    }

    [HttpPost("refresh")]
    public async Task<IActionResult> Refresh()
    {
        var refreshToken = Request.Cookies["refreshToken"];
        if (string.IsNullOrEmpty(refreshToken))
            return Unauthorized("Refresh token missing");

        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";

        // ----- تشخیص استفاده مجدد (Reuse Detection) -----
        var tokenHash = HashToken(refreshToken);
        var existing = await _db.RefreshTokens
            .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);

        if (existing is null)
        {
            // توکن در دیتابیس نیست → ممکن است جعلی باشد
            return Unauthorized("Invalid token");
        }

        if (existing.IsExpired)
        {
            // منقضی شده → به‌سادگی رد کن
            return Unauthorized("Token expired");
        }

        if (existing.IsRevoked)
        {
            // توکن قبلاً باطل شده ولی دوباره ارائه شده → سرقت!
            // کل خانواده را باطل کن
            await _tokenService.RevokeByFamilyAsync(existing.FamilyId, ip);
            return Unauthorized("Token reuse detected. All sessions revoked.");
        }

        var result = await _tokenService.RotateRefreshTokenAsync(refreshToken, ip);
        if (result is null)
            return Unauthorized("Could not rotate token");

        var (newAccess, newRefresh) = result.Value;
        SetRefreshTokenCookie(newRefresh, DateTime.UtcNow.AddDays(7));

        return Ok(new { accessToken = newAccess });
    }

    [HttpPost("revoke")]
    public async Task<IActionResult> Revoke()
    {
        var refreshToken = Request.Cookies["refreshToken"];
        if (string.IsNullOrEmpty(refreshToken))
            return NoContent();

        var ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
        var tokenHash = HashToken(refreshToken);
        var existing = await _db.RefreshTokens
            .FirstOrDefaultAsync(rt => rt.TokenHash == tokenHash);

        if (existing is not null && existing.IsActive)
        {
            await _tokenService.RevokeByFamilyAsync(existing.FamilyId, ip);
        }

        Response.Cookies.Delete("refreshToken");
        return NoContent();
    }

    private void SetRefreshTokenCookie(string token, DateTime expiry)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,
            Secure = true,
            SameSite = SameSiteMode.Strict,
            Expires = expiry
        };
        Response.Cookies.Append("refreshToken", token, cookieOptions);
    }

    private static string HashToken(string token)
    {
        var bytes = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token));
        return Convert.ToBase64String(bytes);
    }

    private static bool VerifyPassword(string password, string hash)
    {
        // نمونه ساده؛ در پروژه واقعی از BCrypt استفاده کنید
        return BCrypt.Net.BCrypt.Verify(password, hash);
    }

    public record LoginDto(string Username, string Password);
}

پیکربندی Program.cs

// Program.cs
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using System.Text;
using TokenRotationDemo.Data;
using TokenRotationDemo.Options;
using TokenRotationDemo.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));

var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get<JwtSettings>()!;

builder.Services.AddDbContext<ApplicationDbContext>(opt =>
    opt.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddSingleton(jwtSettings);
builder.Services.AddScoped<ITokenService, TokenService>();

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtSettings.Issuer,
            ValidAudience = jwtSettings.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
            ClockSkew = TimeSpan.FromSeconds(30)
        };
    });

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// ساخت خودکار دیتابیس برای نمونه
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    db.Database.EnsureCreated();
}

app.UseSwagger();
app.UseSwaggerUI();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

نمونه Seed کاربر

برای تست سریع، یک کاربر نمونه در زمان شروع برنامه بسازید:

// در Program.cs قبل از app.Run()
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
    db.Database.EnsureCreated();

    if (!db.Users.Any())
    {
        db.Users.Add(new User
        {
            Id = Guid.NewGuid(),
            Username = "admin",
            PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456")
        });
        db.SaveChanges();
    }
}

جریان کامل چرخش توکن

۱. ورود (Login)

POST /api/auth/login
Content-Type: application/json

{
  "username": "admin",
  "password": "123456"
}

پاسخ:

{
  "accessToken": "eyJhbGciOiJIUzI1NiIs..."
}

و Cookie زیر تنظیم می‌شود:

Set-Cookie: refreshToken=<random-base64>; HttpOnly; Secure; SameSite=Strict

در دیتابیس، رکوردی با ساختار زیر ایجاد می‌شود:

Id TokenHash FamilyId JwtId ExpiresAt RevokedAt
(GUID) (SHA-256) FAM-1 JIT-1 +7d NULL

۲. چرخش (Refresh)

POST /api/auth/refresh

پاسخ شامل Access Token جدید و Cookie جدید با Refresh Token جدید است. در دیتابیس، رکورد قبلی RevokedAt می‌گیرد و رکورد جدید با همان FamilyId ایجاد می‌شود.

۳. تشخیص سرقت

اگر کاربر (یا مهاجم) با توکن قبلاً استفاده‌شده (RT2) درخواست دهد:

  1. سرور توکن را در دیتابیس پیدا می‌کند.
  2. مشاهده می‌کند که RevokedAt پر است.
  3. کل خانواده FAM-1 باطل می‌شود.
  4. پاسخ 401 Unauthorized با پیام "Token reuse detected" بازگردانده می‌شود.

نکات امنیتی کلیدی

  • هش کردن توکن در دیتابیس: هرگز خود Refresh Token را به‌صورت plain text ذخیره نکنید. در صورت نشت دیتابیس، مهاجم می‌تواند مستقیماً از آن‌ها استفاده کند.
  • HttpOnly + Secure + SameSite=Strict: Refresh Token را فقط در Cookie امن نگه‌داری کنید تا در معرض XSS قرار نگیرد.
  • طول عمر کوتاه برای Refresh Token: ۷ روز معمولاً کافی است. در سامانه‌های حساس‌تر، ۱ تا ۲ روز در نظر بگیرید.
  • استفاده از RandomNumberGenerator: برای تولید توکن از RandomNumberGenerator.GetBytes(64) استفاده کنید، نه از Random.
  • IP و User-Agent را ذخیره کنید: برای تحلیل و تشخیص تقلب مفید است (هرچند IP به‌تنهایی قابل اطمینان نیست).
  • تلاش‌های ناموفق را لاگ کنید: برای تشخیص حملات brute-force روی endpoint‌های /refresh و /login.
  • Binding توکن‌ها به کاربر و دستگاه: در سطح بالاتر امنیتی می‌توانید توکن را به fingerprint دستگاه نیز متصل کنید.
  • استفاده از DPAPI یا Azure Key Vault برای کلید امضای JWT: کلید را در appsettings.json در محیط Production قرار ندهید.
  • فعال‌سازی HTTPS و HSTS: کل ترافیک توکن باید روی HTTPS باشد.

سناریوهای پیشرفته‌تر

توکن‌های reusable در مقابل one-time-use

پیاده‌سازی بالا one-time-use است؛ یعنی هر توکن فقط یک‌بار قابل استفاده است. در برخی سناریوها (مثل اپلیکیشن‌های موبایل آفلاین)، می‌توانید از Reusable Refresh Tokens با Window کوتاه استفاده کنید:

// اجازه استفاده مجدد تا N ثانیه پس از اولین استفاده
// (با ذخیره ReuseWindow در دیتابیس)
if (existing.IsRevoked &&
    (DateTime.UtcNow - existing.RevokedAt!.Value).TotalSeconds < 30)
{
    // بازگرداندن همان توکن جایگزین به جای تولید توکن جدید
    var replacement = await _db.RefreshTokens
        .FirstOrDefaultAsync(rt => rt.TokenHash == existing.ReplacedByTokenHash);
    // ... بازگرداندن Access Token جدید با همان Refresh Token
}

توکن‌های Scoped به دستگاه

می‌توانید توکن را به یک Device ID متصل کنید:

public class RefreshToken
{
    // ... سایر فیلدها
    public string? DeviceId { get; set; }
}

و در زمان refresh چک کنید که DeviceId درخواست‌کننده با مقدار ذخیره‌شده همخوانی داشته باشد.

تست جریان چرخش توکن

می‌توانید با curl یا Swagger، جریان را تست کنید:

# 1. Login
curl -i -X POST https://localhost:5001/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}' \
  -c cookies.txt

# 2. Refresh (اولین چرخش)
curl -i -X POST https://localhost:5001/api/auth/refresh \
  -b cookies.txt -c cookies.txt

# 3. تلاش مجدد با همان توکن قبلی → باید 401 برگرداند
curl -i -X POST https://localhost:5001/api/auth/refresh \
  -b cookies.txt

در مرحله سوم باید پاسخی شبیه زیر دریافت کنید:

{
  "type": "https://tools.ietf.org/html/rfc7235#section-3.1",
  "title": "Unauthorized",
  "status": 401,
  "detail": "Token reuse detected. All sessions revoked."
}

جمع‌بندی

Refresh Token Rotation یک لایه دفاعی حیاتی برای سامانه‌های احراز هویت مبتنی بر JWT است. با پیاده‌سازی چرخش توکن همراه با تشخیص استفاده مجدد، در صورت نشت یک Refresh Token، می‌توانید:

  • پنجره زمانی سوءاستفاده را به چند ثانیه کاهش دهید،
  • سرقت را به‌صورت خودکار شناسایی کنید،
  • تمام نشست‌های کاربر را باطل کنید و او را مجبور به ورود مجدد کنید.

پیاده‌سازی ارائه‌شده در این مقاله شامل تمام قطعات لازم برای یک پروژه واقعی است: مدل داده، سرویس چرخش توکن، endpoint‌های ورود/چرخش/ابطال، تنظیمات امنیتی Cookie و JWT، و تشخیص سرقت. در پروژه‌های Production حتماً موارد زیر را نیز اضافه کنید:

  • ذخیره کلیدها در Azure Key Vault یا AWS Secrets Manager
  • استفاده از PostgreSQL/SQL Server به‌جای SQLite
  • اعتبارسنجی fingerprint دستگاه
  • Rate limiting روی endpoint‌های احراز هویت
  • لاگ‌نویسی ساختاریافته با Serilog و ارسال به Seq/ELK

امنیت یک فرایند پیوسته است؛ چرخش توکن نقطه شروع است، نه پایان مسیر.

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.